</> 技術筆記Tech Notes

從獎金計算談起:什麼是 DSL?

從獎金計算談起:什麼是 DSL?

在企業應用系統的生命週期中,業務規則的頻繁變動無疑是軟體維護的一大挑戰。尤其在獎金計算、風險評估或流程審批等領域,其商業邏輯常因應市場策略而頻繁調整。若將這些易變的規則直接寫死(Hard-coded)於程式碼中,將導致系統僵化、維護成本高昂,且難以快速回應業務需求。每當規則變更,從需求溝通、開發修改、反覆測試到最終部署,整個流程不僅耗時,更大幅提高了導入錯誤的風險,使 IT 部門成為業務推進的瓶頸。

其核心挑戰在於:易變的業務規則與相對穩定的應用程式碼之間的高度耦合

為了解耦此二者,軟體架構中的領域特定語言(DSL)設計模式,提供了一套系統化且高效的解決方案。本文旨在闡述 DSL 的核心概念、設計方法,並透過 C# 與 T-SQL 的實作範例,展示其從設計到實踐的完整流程。

一、領域特定語言(DSL)之定義

領域特定語言(Domain-Specific Language, DSL)是一種專為解決特定問題領域而設計的計算機語言。

DSL 與 Python、Java、C# 等通用程式語言(General-Purpose Language, GPL)在設計哲學上有所區別。GPL 的目標是處理任何類型的計算問題,因此功能全面、語法複雜。相對地,DSL 完全專注於單一領域,其語法和詞彙均源自該領域的專業術語,從而大幅簡化了邏輯的表達。

以「獎金計算」為例,此領域所關心的概念僅限於:

  • 業績 (sales)

  • 目標 (target)

  • 達成率 (achievementRate)

  • 獎金級距 (bonusTier)

  • 獎金係數 (bonusFactor)

因此,一個為獎金計算而設計的 DSL,其語法將圍繞這些詞彙構建,而不會包含通用語言中的迴圈 (for, while)、類別 (class) 或泛型等無關概念。

DSL 的核心價值在於實現業務邏輯(WHAT)與執行引擎(HOW)的關注點分離(Separation of Concerns)。這如同主廚(執行引擎)根據美食評論家(領域專家)撰寫的食譜(DSL)來烹飪。評論家只需定義菜色的風味、食材與呈現方式,而無須關心廚房內的具體烹調步驟。同樣地,領域專家可透過語義清晰的 DSL 來定義及維護規則,而軟體工程師則專注於開發一個能夠解析並執行這些規則的通用引擎。

二、DSL 的設計方法

假設一項簡化版獎金規則如下:

  • 達成率介於 100% (含) 至 120% (不含) 之間,獎金為 業績 * 3%

  • 達成率等於或超過 120%,獎金為 業績 * 5%

  • 其餘情況,獎金為零。

以下將探討如何以不同的 DSL 格式來定義此規則。

方法一:JSON - 通用資料交換格式

JSON (JavaScript Object Notation) 作為一種輕量級的資料交換格式,具備極高的通用性,幾乎所有程式語言都內建了高效能的解析器。其普遍應用於 Web API,使得多數開發者對其語法相當熟悉,是 DSL 的理想入門選擇。

獎金規則範例 (JSON):

{
  "規則名稱": "2025年第三季銷售獎金",
  "獎金級距": [
    {
      "級距名稱": "標準獎金",
      "最低達成率": 1.0,
      "最高達成率": 1.2,
      "獎金係數": 0.03
    },
    {
      "級距名稱": "超級獎金",
      "最低達成率": 1.2,
      "最高達成率": 999,
      "獎金係數": 0.05
    }
  ],
  "預設獎金係數": 0
}
  • 優點:

    • 通用性高:生態系成熟,機器解析效率高。

    • 結構清晰:鍵值對的結構清楚地定義了資料的層級。

  • 缺點:

    • 語法嚴格:對括號、引號和逗號的要求嚴格,例如在陣列最後一個元素後誤加逗號,便會導致解析失敗,人工編寫時需特別注意。

    • 缺乏註解:原生 JSON 格式不支援註解,難以在規則旁加註說明,對複雜規則的可維護性造成挑戰。

方法二:YAML - 提升可讀性的選擇

YAML (YAML Ain’t Markup Language) 是 JSON 的超集,它使用縮排來表示層級關係,旨在提升設定檔的可讀性與可維護性。

獎金規則範例 (YAML):

規則名稱: 2025年第三季銷售獎金

# 定義不同達成率的獎金級距
獎金級距:
  - 級距名稱: 標準獎金
    最低達成率: 1.0
    最高達成率: 1.2
    獎金係數: 0.03

  - 級距名稱: 超級獎金
    最低達成率: 1.2
    最高達成率: 999 # 代表無上限
    獎金係數: 0.05

預設獎金係數: 0
  • 優點:

    • 可讀性極佳:語法乾淨,非常適合非技術背景人員閱讀、審核甚至直接維護,促進了業務與技術團隊間的協作。

    • 支援註解:可透過 # 符號添加註解,提升可維護性。

  • 缺點:

    • 對縮排敏感:錯誤的縮排(例如使用 Tab 而非空格)可能導致解析失敗,需要搭配支援 YAML 的編輯器來輔助。

方法三:自訂腳本 - 追求極致的彈性

此為最具表達能力與彈性的方法,同時也伴隨著最高的實作複雜度。此類 DSL 的語法被設計成一種流暢的應用程式介面(Fluent API)或微型腳本語言。

獎金規則範例 (自訂腳本):

// 規則檔案: Q3_bonus.bonus_dsl

rule "2025年第三季銷售獎金"

when achievement_rate >= 1.2 {
    bonus = sales * 0.05
}

when achievement_rate >= 1.0 {
    bonus = sales * 0.03
}

default {
    bonus = 0
}
  • 優點:

    • 彈性與表達能力最強:可直接在 DSL 中實現複雜的條件判斷、變數賦值等邏輯。

    • 表現力豐富:語法可設計得非常貼近業務術語。

  • 缺點:

    • 開發成本極高:其主要挑戰在於需要開發專屬的解析器(Parser)與直譯器(Interpreter),涉及詞法分析、語法分析等編譯原理的專業知識。

三、DSL 執行引擎之實作

設計完成後,DSL 仍是一份靜態規則,需要一個執行引擎 (Execution Engine) 來解析並執行。以下將分別以 C# 與 T-SQL 展示引擎的實作範例。

C# Console 實作範例

本節將展示於 .NET 環境中,如何實作一個讀取 JSON DSL 並在記憶體中執行運算的引擎。

步驟一:定義 DSL 對應的 C# 模型 (POCOs)

首先,定義與 DSL 結構對應的 C# 資料模型,以便進行強型別的反序列化操作。

using System.Text.Json.Serialization;

// 對應 "獎金級距" 陣列中的每個物件
public class BonusTier
{
    [JsonPropertyName("級距名稱")]
    public string TierName { get; set; }

    [JsonPropertyName("最低達成率")]
    public decimal MinRate { get; set; }

    [JsonPropertyName("最高達成率")]
    public decimal MaxRate { get; set; }

    [JsonPropertyName("獎金係數")]
    public decimal BonusFactor { get; set; }
}

// 對應整個 JSON 檔案的根物件
public class BonusRuleSet
{
    [JsonPropertyName("規則名稱")]
    public string RuleName { get; set; }

    [JsonPropertyName("獎金級距")]
    public List<BonusTier> Tiers { get; set; }

    [JsonPropertyName("預設獎金係數")]
    public decimal DefaultFactor { get; set; }
}

步驟二:建立獎金計算引擎

此類別為執行計算的核心,封裝了業務邏輯。

public class BonusCalculator
{
    private readonly BonusRuleSet _rules;

    public BonusCalculator(BonusRuleSet rules)
    {
        _rules = rules;
    }

    public decimal CalculateBonus(SalesData data)
    {
        if (data.SalesTarget <= 0) return 0;

        decimal achievementRate = data.SalesAmount / data.SalesTarget;

        var applicableTier = _rules.Tiers
            .FirstOrDefault(tier => achievementRate >= tier.MinRate && achievementRate < tier.MaxRate);

        decimal bonusFactor = applicableTier?.BonusFactor ?? _rules.DefaultFactor;

        return data.SalesAmount * bonusFactor;
    }
}

步驟三:主程式 - 整合與執行

Program.cs 整合了所有組件,模擬了從讀取 DSL、準備資料到執行計算的完整流程。

using System.Text.Json;

public class Program
{
    public static void Main(string[] args)
    {
        string jsonDsl = @"
        {
          ""規則名稱"": ""2025年第一季基礎獎金"",
          ""獎金級距"": [
            { ""級距名稱"": ""標準獎金"", ""最低達成率"": 1.0, ""最高達成率"": 1.2, ""獎金係數"": 0.03 },
            { ""級距名稱"": ""超級獎金"", ""最低達成率"": 1.2, ""最高達成率"": 999.0, ""獎金係數"": 0.05 }
          ],
          ""預設獎金係數"": 0.0
        }";

        var bonusRules = JsonSerializer.Deserialize<BonusRuleSet>(jsonDsl);
        Console.WriteLine($"已載入獎金規則: {bonusRules.RuleName}\n");

        var salesData = new List<SalesData>
        {
            new SalesData { EmployeeID = 101, EmployeeName = "王小明", SalesAmount = 110000.00m, SalesTarget = 100000.00m },
            new SalesData { EmployeeID = 102, EmployeeName = "陳大膽", SalesAmount = 150000.00m, SalesTarget = 120000.00m },
            new SalesData { EmployeeID = 103, EmployeeName = "張三",   SalesAmount = 80000.00m,  SalesTarget = 100000.00m },
            new SalesData { EmployeeID = 104, EmployeeName = "李四",   SalesAmount = 120000.00m, SalesTarget = 100000.00m }
        };

        var calculator = new BonusCalculator(bonusRules);

        Console.WriteLine($"{"員工ID",-10}{"姓名",-10}{"達成率",-12}{"獎金金額",-12}");
        Console.WriteLine(new string('-', 44));

        foreach (var employee in salesData)
        {
            decimal bonus = calculator.CalculateBonus(employee);
            decimal rate = employee.SalesTarget > 0 ? (employee.SalesAmount / employee.SalesTarget) : 0;
            Console.WriteLine($"{employee.EmployeeID,-10}{employee.EmployeeName,-10}{rate,-12:P1}{bonus,-12:C}");
        }
    }
}

public class SalesData
{
    public int EmployeeID { get; set; }
    public string EmployeeName { get; set; }
    public decimal SalesAmount { get; set; }
    public decimal SalesTarget { get; set; }
}

T-SQL 實作範例

此範例展示如何在資料庫內部,使用單一預存程序來建立一個完整的 DSL 執行引擎。此做法能有效減少資料傳輸延遲,並充分利用資料庫強大的集合運算能力。

步驟一:準備環境與資料

執行此腳本以建立必要的資料表與模擬數據。

IF OBJECT_ID('dbo.SalesData', 'U') IS NOT NULL DROP TABLE dbo.SalesData;
CREATE TABLE dbo.SalesData (
    EmployeeID INT PRIMARY KEY,
    EmployeeName NVARCHAR(50),
    SalesAmount DECIMAL(18, 2),
    SalesTarget DECIMAL(18, 2)
);

IF OBJECT_ID('dbo.BonusResults', 'U') IS NOT NULL DROP TABLE dbo.BonusResults;
CREATE TABLE dbo.BonusResults (
    EmployeeID INT PRIMARY KEY,
    EmployeeName NVARCHAR(50),
    AchievementRate DECIMAL(18, 4),
    BonusAmount DECIMAL(18, 2)
);

INSERT INTO dbo.SalesData (EmployeeID, EmployeeName, SalesAmount, SalesTarget) VALUES
(101, '王小明', 110000.00, 100000.00),
(102, '陳大膽', 150000.00, 120000.00),
(103, '張三',   80000.00, 100000.00),
(104, '李四',   120000.00, 100000.00);

步驟二:建立執行引擎預存程序

此預存程序作為核心引擎,接收 JSON 規則並在資料庫內部完成所有計算。

CREATE OR ALTER PROCEDURE dbo.usp_CalculateBonusFromDsl
    @JsonRules NVARCHAR(MAX)
AS
BEGIN
    SET NOCOUNT ON;

    -- A. 解析 DSL 規則
    IF OBJECT_ID('tempdb..#BonusTiers') IS NOT NULL DROP TABLE #BonusTiers;
    SELECT *
    INTO #BonusTiers
    FROM OPENJSON(@JsonRules, '$.獎金級距')
    WITH (
        TierName        NVARCHAR(50)    '$.級距名稱',
        MinRate         DECIMAL(18, 4)  '$.最低達成率',
        MaxRate         DECIMAL(18, 4)  '$.最高達成率',
        BonusFactor     DECIMAL(18, 4)  '$.獎金係數'
    );
    DECLARE @DefaultFactor DECIMAL(18, 4) = JSON_VALUE(@JsonRules, '$.預設獎金係數');

    -- B. 準備計算資料
    IF OBJECT_ID('tempdb..#CalculationData') IS NOT NULL DROP TABLE #CalculationData;
    SELECT
        EmployeeID, EmployeeName, SalesAmount, SalesTarget,
        CASE WHEN SalesTarget > 0 THEN SalesAmount / SalesTarget ELSE 0 END AS AchievementRate
    INTO #CalculationData
    FROM dbo.SalesData;
    
    -- C. 應用規則並計算獎金
    TRUNCATE TABLE dbo.BonusResults;
    INSERT INTO dbo.BonusResults (EmployeeID, EmployeeName, AchievementRate, BonusAmount)
    SELECT
        c.EmployeeID, c.EmployeeName, c.AchievementRate,
        c.SalesAmount * ISNULL(t.BonusFactor, @DefaultFactor) AS BonusAmount
    FROM 
        #CalculationData c
    LEFT JOIN 
        #BonusTiers t ON c.AchievementRate >= t.MinRate AND c.AchievementRate < t.MaxRate;
    
    -- D. 清理
    DROP TABLE #BonusTiers;
    DROP TABLE #CalculationData;

    PRINT '獎金計算完成!';
END
GO

步驟三:執行與驗證

將 DSL JSON 字串傳遞給預存程序並驗證結果。

DECLARE @MyDslJson NVARCHAR(MAX) = N'
{
  "規則名稱": "2025年第一季基礎獎金",
  "獎金級距": [
    { "級距名稱": "標準獎金", "最低達成率": 1.0, "最高達成率": 1.2, "獎金係數": 0.03 },
    { "級距名稱": "超級獎金", "最低達成率": 1.2, "最高達成率": 999.0, "獎金係數": 0.05 }
  ],
  "預設獎金係數": 0.0
}';

EXEC dbo.usp_CalculateBonusFromDsl @JsonRules = @MyDslJson;

SELECT * FROM dbo.BonusResults;

四、DSL 設計的核心原則與最佳實務

在理解 DSL 的基本概念與實現方法後,一套成功的 DSL 應遵循以下設計原則:

  1. 以領域為核心 DSL 的語法、詞彙與結構,皆須直接對應特定領域的專業術語與思維模式。例如,不應使用 var1, op2 等泛稱,而應使用 業績, 獎金級距 等具體詞彙。

  2. 精簡與專注 DSL 應僅包含解決領域問題所需的功能。避免加入不相關的通用程式語言特性(如檔案 I/O),是防止其退化為設計不良的通用語言之關鍵。

  3. 易於上手與高可讀性 DSL 的主要目標之一是讓非開發人員也能夠使用,因此其學習曲線必須相當平緩,語法應力求直觀且一致。

  4. 採用宣告式,而非指令式 優秀的 DSL 應專注於描述「要什麼」(WHAT),而非「如何做」(HOW)。宣告式規則將邏輯定義與執行細節分離,使規則更清晰,也賦予執行引擎更大的優化空間。此概念如同告知計程車司機目的地,而非給予轉彎指令,司機(引擎)可自行選擇最佳路徑。

  5. **可組合性 ** 應允許將簡單的規則或元件組合成更複雜的邏輯,如同堆疊樂高積木一般。例如,總獎金 = 基本獎金 + 專案獎金,其中 基本獎金專案獎金 可各自獨立定義。

  6. 提供清楚的回饋 執行引擎必須能在規則語法或語意有誤時,提供清晰、有用的錯誤訊息。例如,應能提示「語意錯誤:‘超級獎金’的最低達成率(1.2)不可小於’標準獎金’的最高達成率(1.2),級距區間不可重疊。」

五、結論

綜上所述,領域特定語言(DSL)並非單一技術,而是一種重要的軟體設計思想。它在多變的業務需求與穩定的軟體架構之間建立了有效的溝通橋樑,透過將業務邏輯外部化為可由領域專家維護的規則,顯著提升了系統的彈性、可維護性與回應速度。選擇合適的 DSL 格式與實作方法,是一項能為複雜系統帶來長期價值的架構決策。